静的型付けをもつJavaScriptへのトランスコンパイル言語を味見してみた その2
前回の訂正
前回の続きでHaxe、JSX、TypeScriptの言語機能についてまとめていきたいと思いますが、その前に前回の記事の内容に訂正があります。
- JSXのコンパイラはJavaScriptで書かれている。
- JSXとTypeScriptの型推論に関して、関数の型が明示されていれば関数の引数と戻り値の型は省略可能。
前回の記事も訂正してあります。
では、気を取り直して前回の続きに入りたいと思います。
型パラメータ
HaxeとJSXはクラスに型パラメータを持つことができます。JavaやC#のジェネリクスに相当するものです。JSXは常に非変ですが、Haxeは条件によっては共変や反変になります。TypeScriptは正式リリースまでに実装される予定があるようです。
以下、前回作ったFruitインターフェース、Appleクラス、Fujiクラスを使ったサンプルです。
Haxe
class Bag<T> { private var storage: Array<T>; public function new() { storage = []; } public function push(t: T) { storage.push(t); } public function pop(): T { return storage.pop(); } } //... // class Apple implements Fruit // class Fuji extends Apple //... var fruitBag = new Bag<Fruit>(); fruitBag.push(new Apple()); fruitBag.push(new Fuji()); trace(fruitBag.pop().getName()); // ふじりんご trace(fruitBag.pop().getName()); // りんご var fujiBag = new Bag<Fuji>(); fujiBag.push(new Fuji()); // fujiBag.push(new Apple()); // コンパイルエラー
JSX
class Bag.<T> { var storage: Array.<T>; function constructor() { this.storage = new Array.<T>(); } function push(t: T): void { this.storage.push(t); } function pop(): T { return this.storage.pop(); } } //... // class Apple implements Fruit // class Fuji extends Apple //... var fruitBag = new Bag.<Fruit>(); fruitBag.push(new Apple()); fruitBag.push(new Fuji()); log fruitBag.pop().getName(); // ふじりんご log fruitBag.pop().getName(); // りんご var fujiBag = new Bag.<Fuji>(); fujiBag.push(new Fuji()); // fujiBag.push(new Apple()); // コンパイルエラー
モジュールと名前空間
単一のソースファイルもしくはクラスにすべてのスクリプトを記述する場合、アプリケーションが少し大きくなってくるとコード間の関係が見えにくくなりがちですので、機能ごとに分割したくなると思います。分割した機能間の依存関係の定義をシンプルにしてくれるのがモジュールで、この仕組みを利用するとソースコードの見通しを良くすることができます。また、名前空間があると他のソースコードとの間で名前が衝突する危険性を大幅に減らすことができます。
Haxe, JSX, TypeScriptはソースコード間の関係を整理するための仕組みをそれぞれ提供しています。
Haxe
HaxeはActionScript3.0と似た構文でパッケージを宣言して名前空間を定義することができます。パッケージは、ディレクトリがメインクラスを起点とした相対パスに対応している必要があります。また、class, interfaceなどはprivateキーワードを指定することによって、ファイル内でのみ参照することができるようになります。
fruitbasket/Fruit.hx
package fruitsbasket; interface Fruit { function getName(): String; } class Apple implements Fruit { public function new() { } public function getName() { return "りんご"; } } private class Banana implements Fruit { public function new() { } public function getName() { return "バナナ"; } }
Main.hx
package; import fruitsbasket.Fruit; class Main { static function main() { var fruit: Fruit = new Apple(); // var banana: Banana = new Banana(); // Bananaはprivateクラスなので参照できない } }
JSX
JSXは外部.jsxファイルをimportキーワードでインポートすることができます。
fruitbasket.jsx
interface Fruit { function getName(): string; } class Apple implements Fruit { function constructor() { } override function getName(): string { return "りんご"; } } class Banana implements Fruit { function constructor() { } override function getName(): string { return "バナナ"; } }
main.jsx
import "./fruitbasket.jsx"; class _Main { static function main(args: string[]): void { var fruit: Fruit = new Apple(); } }
クラス名の衝突を避けるために名前空間を定義したい場合は、インポート側でintoキーワードを利用してインポートする名前空間を指定します。
import "./fruitbasket.jsx" into fb; class _Main { static function main(args: string[]): void { var fruit: fb.Fruit = new fb.Apple(); } }
fromキーワードでモジュールファイル内の一部のクラスのみをインポートすることもできます。
import Fruit, Apple from "./fruitbasket.jsx"; class _Main { static function main(args: string[]): void { var fruit: Fruit = new Apple(); // var banana: Banana = new Banana(); // Bananaをインポートしていないので参照できない } }
TypeScript
Internal Modules
TypeScriptはモジュール管理の仕組みを提供しています。モジュールを定義するには、まずmoduleキーワードで名前空間を定義し、その内部にクラスや関数などを定義します。moduleの内部のクラスや関数などはそのままだと外部から参照できませんので、外部から参照できるようにしたいクラスなどの宣言の先頭にexportキーワードを付加します。あとは、モジュールを使用する側で使用するモジュールファイルのパスを記述するだけです。
下記はモジュール管理のサンプルコードです。
fruitbasket.ts
module FruitsBasket { export interface Fruit { getName(): string; } export class Apple implements Fruit { constructor() { } getName() { return "りんご"; } } class Banana implements Fruit { constructor() { } getName() { return "バナナ"; } } }
main.ts
// fruitbasket.tsに定義されたモジュールの読み込み /// <reference path="./fruitbasket.ts"/> var fruit: FruitsBasket.Fruit = new FruitsBasket.Apple(); // var banana: FruitsBasket.Banana = new FruitsBasket.Banana(); // Bananaはexportしていないので参照できない
importキーワードを利用してエイリアス名を与えることもできます。
import fb = FruitsBasket; var fruit: fb.Fruit = new fb.Apple();
External Modules
TypeScriptのサンプルを見ていると、Internal Modulesの形式でモジュールを扱っているものが多いので、通常はこの仕組みを使ってモジュールを管理するのがいいと思われます。しかし、node.jsやRequireJSを利用した場合など、CommonJSやAMDのモジュールシステムに準拠する形式で管理したい場合もあると思います。TypeScriptはこれらのモジュールシステムもサポートしており、下記のようにimportキーワードでインポートを宣言します。
import foo = module("モジュール名");
CommonJSの形式を使いたい場合はそのままコンパイルします。
fruitbasket.ts
// TypeScript export interface Fruit { getName(): string; } export class Apple implements Fruit { constructor() { } getName() { return "りんご"; } }
main.ts
// TypeScript import fb = module("./external_module"); var fruit: fb.Fruit = new fb.Apple();
このコードは下記のようなCommonJSに対応したJavaScriptを出力します。
fruitbasket.js
// 出力されたJavaScript var Apple = (function () { function Apple() { } Apple.prototype.getName = function () { return "りんご"; }; return Apple; })(); exports.Apple = Apple;
main.js
// 出力されたJavaScript var fb = require("./fruitbasket"); var fruit = new fb.Apple();
AMDを使いたい場合は、コンパイル時にオプションを付けます。
$ tsc --module amd "コンパイル対象ファイルのパス"
先ほどのコードをAMDオプション付きでコンパイルすると、下記のようなAMDに対応したJavaScriptが出力されます。
fruitbasket.js
// --module amd オプションを指定してコンパイル時の出力されたJavaScript define(["require", "exports"], function(require, exports) { var Apple = (function () { function Apple() { } Apple.prototype.getName = function () { return "りんご"; }; return Apple; })(); exports.Apple = Apple; })
main.js
// --module amd オプションを指定してコンパイル時の出力されたJavaScript define(["require", "exports", "./fruitbasket"], function(require, exports, __fb__) { var fb = __fb__; var fruit = new fb.Apple(); })
ライブラリとの相互運用
各言語ともに、外部ライブラリを型安全に利用するための「インターフェース」を定義する仕組みを提供しています。実行時にライブラリがロードされている状態にするのは実装者の責任です。
Haxe
Haxeではexternキーワードを利用して外部ライブラリのオブジェクトの型を定義します。externキーワードで修飾されたクラスは実行時に実装が用意されるとコンパイラに認識され、生成されたJavaScriptコードに定義が含まれなくなります。
下記コードは外部ライブラリのFruitHolderオブジェクトがgetFruitメソッドを持っている場合のサンプルです。externキーワードで修飾されたFruitHolderクラスでメンバを定義しています。
extern class FruitHolder { public function getFruit(): Fruit; } // ... // 実行時に「fruitHolder」でアクセスできる外部ライブラリオブジェクトを取得してキャスト var fruitHolderObj = cast(untyped __js__("fruitHolder"), FruitHolder); var fruit = fruitHolderObj.getFruit();
また、Haxeはオーバーロードをサポートしていませんが、これでは外部ライブラリとの運用に問題が出てしまいます。そのため、Externでは@:overloadメタデータを利用することによってメソッドのオーバーロードを例外的に許可しています。実際はExternでなくても@:overloadメタデータは使うことはできますが、@:overloadメタデータで定義されたメソッドに関してはコンパイラは型チェックを一切行いません。したがって、外部ライブラリの型定義以外の目的での使用は避けた方が良さそうです。
extern class FruitHolder { // オーバーロードされたメソッドを定義 @:overload(function(name: String): Fruit) public function getFruit(): Fruit; }
なお、Externの本体である外部ライブラリの実装はHaxeによって生成されたJavaScriptコードより先にロードされている必要があります。
JSX
JSXではnativeキーワードを利用して外部ライブラリの型を定義します。定義したクラスが実行時に外部ライブラリによって定義される場合は、通常のクラスと同様にインスタンス化して利用できます。
ただし、JSXはnativeキーワードで定義したクラスの型をキャストするコードを書くと、実行時にinstanceof演算子で定義したクラスのインスタンスであるかチェックを行うJavaScriptコードを出力します。対応するクラスが定義されているライブラリならいいのですが、そうでない場合は__fake__キーワードで実行時にクラスが定義されないことをコンパイラに知らせた上で、__noconvert__キーワードで実行時にinstanceof演算子によるチェックをしないよう指定する必要があります。
定義したクラスが実行時に外部ライブラリによって定義される場合
// 外部ライブラリによって定義されるFruitHolderクラスを定義 native class FruitHolder { function getFruit(): Fruit; } // ... var fruitHolderObj = new FruitHolder(); var fruit = fruitHolderObj.getFruit();
定義したクラスが実行時に外部ライブラリによって定義されない場合
// 外部ライブラリのインスタンスをFruitHolderというクラスとして擬似的に扱う native __fake__ class FruitHolder { function getFruit(): Fruit; } // ... // 外部ライブラリオブジェクトが実行時に「fruitHolder」でアクセスできるとする // 外部ライブラリオブジェクトを取得してキャスト var fruitHolderObj = js.global["fruitHolder"] as __noconvert__ FruitHolder; var fruit = fruitHolderObj.getFruit();
TypeScript
TypeScriptの場合は、ライブラリのメンバのシグニチャをinterfaceで定義した上で、declareキーワードを利用したアンビエント宣言を利用します。アンビエント宣言は、実行時に定義済みであることが前提となる変数や関数についてコンパイラに対して宣言します。
interface FruitHolder { getFruit(): Fruit; } // ... // この変数宣言はJavaScript出力時に消去される declare var fruitHolder: FruitHolder; // このコードはJavaScript出力時にそのままの残るので、fruitHolderオブジェクトのgetFruitメソッドを呼び出す処理が行われる var fruit = fruitHolder.getFruit();
拡張子が.d.tsのソースファイルを作成すると、そのファイル内のソースコードは全てアンビエント宣言であるとみなされます。このソースファイル内のコードは、変数等をdeclareを付けずに宣言してもコンパイル時には全てdeclareが付加されたものとみなされます。
式
Haxeは、if, switch, tryがただの制御構文でなく値を返す「式」になっています。
// if式 var bool = true; var msg = if (bool) { "foo"; } else { "bar"; } trace(msg); // foo // switch式 var one = 1; var msg2 = switch (one) { case 1: "foo"; case 2, 3: "bar"; default: "boo"; } trace(msg2); // foo(Haxeのswitchはフォールスルーしない) // try式 var msg3 = try { throw "error"; } catch (errorMsg: String) { errorMsg; } catch (errorCode: Int) { Std.string(errorCode); } trace(msg3); // error
列挙型
Haxeは列挙型を持っています。Haxeの列挙型はコンストラクタを持っており、switch式でこの列挙型を扱う場合にはコンストラクタ型のパターンマッチの機能が提供されます。列挙型は型パラメータを利用することが可能ですが、コンストラクタの引数の型でのパターンマッチはできないようです。
// 飲み会の出欠回答を表す列挙 enum Answer { Yes(dateStr: String); // 出席の場合は希望日も回答 No; // 欠席 } // ... var answer = Yes("12/01"); var msg = switch (answer) { case Yes(dateStr): "出席します!希望日:" + dateStr; case No: "出席できません。。。"; } trace(msg); // 出席します!希望日:12/01
構造的部分型
HaxeとTypeScriptはScalaで提供されているような構造的部分型の機能を提供します。これは、決められたメンバを持ったオブジェクトは全て特定の型の派生型とみなすというもので、継承とは無関係にオブジェクトの構造によって型を決定するインターフェースのようなものです。この機能を利用するとダッグタイピングのようなことを型安全に行うことができます。
Haxe
typedef Vector2DType = { var x: Float; var y: Float; } class Vector3D { public var x: Float; public var y: Float; public var z: Float; public function new(x: Float, y: Float, z: Float) { this.x = x; this.y = y; this.z = z; } } // ... var vector2d: Vector2DType = { x: 1, y: 2 }; trace("x:" + vector2d.x + " y:" + vector2d.y); // x:1 y:2 var vector3d: Vector2DType = new Vector3D(1, 2, 3); trace("x:" + vector3d.x + " y:" + vector3d.y); // x:1 y:2
TypeScript
interface Vector2DType { x: number; y: number; } class Vector3D { constructor(public x: number, public y: number, public z: number) { } } // ... var vector2d: Vector2DType = { x: 1, y: 2 }; console.log("x:" + vector2d.x + " y:" + vector2d.y); // x:1 y:2 var vector3d: Vector2DType = new Vector3D(1, 2, 3); console.log("x:" + vector3d.x + " y:" + vector3d.y); // x:1 y:2
ソースマップ
各言語で生成されたJavaScriptを実行する際、せっかくブラウザのデバッガを使っても対応するコードがどこだか分かりづらいことが多いと思います。この問題を緩和するために、各言語のソースコードと生成されたJavaScriptコードを紐付けて、オリジナル言語のソースコードでのステップ実行を可能とする機能が提供されています。
この機能を利用するには、コンパイル時にコンパイルオプションを指定した上で、ChromeのEnable Source Mapオプションを有効にする必要があります。
Haxe
-debug
JSX
--enable-source-map
TypeScript
-sourcemap
Chromeのソースマップ設定
Haxeのコードでデバッグ
jQueryとnode.js + Expressを利用したサンプル
Haxe
ソースコードはGitHubで公開しています。
クライアント
HaxeにはjQueryExternというjQuery用のExternがありますので、それを利用しています。new JQueryが面倒です。JQueryクラスにエイリアス名を与えてもいいのですが、"$"はHaxeの仕様上名前に利用できない上に"new"キーワードはどちらにせよ必要なのでそのまま使っています。
package; import js.Lib; import js.Dom; import jQuery.JQuery; typedef JQEvent = jQuery.Event; class Client { static function main() { new JQuery(function(e: Event) { new JQuery("#button").click(function(event: JQEvent) { JQueryStatic.get(Lib.window.location.href + "data", null, function(data) { new JQuery("#text-div").append(data); }); }); }); } }
サーバー
こちらも同じくhaxenodeというnode.js用のExternライブラリがありますので、そちらを利用しています。Expressはいい感じのExternライブラリが見つからなかったので、Dynamic型を利用して対応しています。型安全でなくなるので、本来はできるだけExternを定義するべきだと思います。また、26行目はstaticという名前のメソッドを呼び出していますが、staticはHaxeの予約語で利用できないため、__js__マクロで直接JavaScriptコードを記述しています。
package; import js.Node; import routes.IndexRoute; import routes.DataRoute; class App { static function main() { var express: Dynamic = Node.require("express"); var http = Node.http; var indexRoute = new IndexRoute(); var dataRoute = new DataRoute(); var path = Node.path; var app: Dynamic = express(); app.configure(function() { app.set("port", 3000); app.set("views", Node.__dirname + "/views"); app.set("view engine", "jade"); app.use(express.favicon()); app.use(express.logger("dev")); app.use(express.bodyParser()); app.use(express.methodOverride()); app.use(app.router); untyped __js__("app.use(express.static(path.join(__dirname, 'public')))"); }); app.get("/", indexRoute.index); app.get("/data", dataRoute.data); http.createServer(app).listen(app.get("port"), function() { trace("Express server listening on port " + app.get("port")); }); } }
JSX
ソースコードはGitHubで公開しています。
クライアント
nativeキーワードによるライブラリのシグニチャ定義を必ずする必要がありそうです。しかし、ライブラリに対応する定義ファイルを一度作ってしまえば、あとはわりとすっきり書けますし何より型安全です。
通常のコンパイル後のソースは、そのままではmainメソッドに記述した処理が実行されません。--executable web オプションを指定してコンパイルして下さい。
import "js.jsx"; import "js/web.jsx"; class _Main { static function main(args: string[]): void { var jq = js.global["$"] as __noconvert__ (variant) -> jQueryObject; var JQ = js.global["$"] as __noconvert__ jQueryStatic; jq((): void -> { jq("#button").click((event) -> { JQ.get(dom.window.location.href + "data", null, (data: string): void -> { jq("#text-div").append(data); }); }); }); } } native __fake__ class jQueryObject { function constructor(func: (variant) -> variant); function click(handler: (variant) -> void): jQueryObject; function append(elem: variant): jQueryObject; } native __fake__ class jQueryStatic { function get(url: string, data: variant, success: (string) -> void): variant; }
サーバー
JSXプロジェクトのGitHubリポジトリ内に、jsx-nodejsというnode.jsを動かすライブラリがあったのですが、Expressを併用するのは厳しそうですのでサンプルは作成しませんでした。またnode.js対応自体について、GitHubにIssueとしてあがっているようですので、対応を待ったほうが良さそうです。
TypeScript
ソースコードはGitHubで公開しています。
クライアント
TypeScript公式のサンプルにjQueryの宣言ソースファイルがありましたので、そちらを利用しています。宣言ソースファイル内で"$"がアンビエント宣言されているため、JavaScriptでjQueryを利用するときと同じように記述できます。
/// <reference path="../../typings/jquery.d.ts" /> $(() => { $("#button").click(event => { $.get(window.location.href + "data", null, data => $("#text-div").append(data); ); }); });
サーバー
こちらも、TypeScript公式にnode.jsとExpressの宣言ソースファイルがありましたので、そちらを利用しています。express.d.tsは15行目に対応できるよう少しいじってあります。やはりこちらもJavaScriptで書いたときとほぼ同じ記述ができます。
/// <reference path="./typings/node.d.ts"/> /// <reference path="./typings/express.d.ts"/> import http = module("http") import express = module("express") import routes = module("./routes/index") import dataRoute = module("./routes/data") import path = module("path") var app = express(); app.configure(() => { app.set("port", 3000); app.set("views", __dirname + "/views"); app.set("view engine", "jade"); app.use(express.favicon()); app.use(express.logger("dev")); app.use(express.bodyParser()); app.use(express.methodOverride()); app.use(app.router); app.use(express.static(path.join(__dirname, "public"))); }) app.get("/", routes.index); app.get("/data", dataRoute.data); http.createServer(app).listen(app.get("port"), () => console.log("Express server listening on port %d", app.get("port")); );
まとめ
とてもざっくりではありますが、Haxe, JSX, TypeScriptそれぞれの言語機能の紹介と簡単なサンプルを作成しました。今の時点で私が感じている各言語の印象をまとめてみたいと思います。
Haxe
- 言語機能が豊富で強力
- ここまでできるならJSXみたいなmixinも欲しい(Objective-Cのカテゴリのようなことが実現できるusingを使ったmixinはあります)
- 匿名関数をもう少し簡潔に書けたらいいのに
- 5年以上前から開発されている言語なので、ドキュメントやWeb上の情報がわりと充実している
- 外部ライブラリを使うためのExternのライブラリもたくさんある
- 外部ライブラリを利用するにあたっては、Dynamic型やuntypedでの逃げ道が確保されているのは安心
とにかく強力な言語だなと思いました。型付けがしっかりしているにも関わらず、様々な言語機能のおかげで非常に柔軟にコードを記述することができそうです。列挙型のパターンマッチは特に記述量を圧縮できそうです。
Haxeを使った開発は既に利用実績があるようですし、業務でも安心して使うことができそうです。
JSX
- 型付けを強制させることができる
- mixinが提供されているのは魅力的
- privateメンバを作りたい
- ドキュメントもWeb上の情報もとても少ない
- ライブラリのシグニチャ定義のソースも公開されていない
- 開発環境どうしよう
型については、デバッグモードでコンパイルすると出力したJavaScriptにアサーションまで仕込んでくれる徹底ぶりが頼もしいです。しかし、その徹底ぶりのせいで、型に縛られないJavaScriptの既存ライブラリを気軽に利用しづらい感覚があります。ライブラリのシグニチャ定義のソースコードがもっと充実してくればいい感じになりそうな気がします。
ただ、ライブラリとのインターフェースが窮屈な代わりに、JSXだけで完結する部分、特に大きなドメインロジックを書く際に力を発揮しそうだなと思いました。また、息の長いプロジェクトなどでは、最初にライブラリのシグニチャ定義を作ってしまえば、あまり運用に気を遣わずとも開発後の保守フェーズまで型安全な状態を維持できそうです。
TypeScript
- 一番利用するハードルが低いかも
- CoffeeScriptに近い感覚で使えそう
- もともとの記述量が少ない上に、インテリセンスが加わるとさらにタイプ数が減る
- 外部ライブラリの利用がとても素直で簡単
- 型推論できない場面で型情報書かないとany型になってしまうのが柔軟な反面ちょっと怖い
- 正式リリースまでの型パラメータと列挙型のサポートに期待
JavaScriptのシンタックスシュガーといった感覚で書ける上に、型がはっきりしているので後からコードを読みやすいです。ただし、油断するとすぐにany型になってしまうので、息の長いプロジェクトではだんだん型付けがぐだぐだにならないか心配です。とはいえ、個人で使う分にはその辺りは特に問題になりませんし、なにより書いているときの感覚がとても軽いので気持ちいいです。今から、正式リリースまでにゆっくり仕様を追いかけていって損はなさそうな気がします。
ともあれ、どの言語もクラスベースのOOP、関数を含む静的型付け、名前空間やモジュール機能などがしっかりと提供されているので、それだけでも使う価値があると思います。
余談ですが、どの言語も型推論が強力なので型情報はあまり書く必要がありませんが、メソッド定義などに関してはある程度型情報を明示した方がいいと思っています。型情報自体も一種のドキュメントになりますし、コードを見たときにプログラマが型情報を推測する必要がなくなります。
正直3つの言語を平行して調べるのはかなりしんどかったので、各言語の仕様についてあまり深いところは見ることができてはいません。ただ、それでもこの記事は気になる言語を見つけるきっかけぐらいにはなるかなと思っています。少しでも読んで下さった方のお役に立てば幸いです。
参考サイト
TypeScript Part2: AMD and CommonJS
JSXに「このオブジェクトはfooを持っているはず」と教えるには